import nock from 'nock'
import { v4 as uuidv4 } from 'uuid'
import type { Services } from '@wdio/types'

import WebDriverMock from './WebDriverMock.js'

import { NO_SUCH_ELEMENT } from './constants.js'
import { newSession, deleteSession } from './mocks/newSession.js'

const ELEMENT_ID = '401c0039-3306-6a46-a98d-f5939870a249'
const ELEMENT_REFETCHED = '80d860d0-b829-f540-812e-7078eb983795'
const ELEMENT_ALT = '8bf4d107-a363-40d1-b823-d94bdbc58afb'

const ELEM_PROP = 'element-6066-11e4-a52e-4f735466cecf'

export default class WebdriverMockService implements Services.ServiceInstance {
    private _browser?: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser
    private _mock = new WebDriverMock()

    constructor () {
        this.init()
    }

    init() {
        // define required responses
        this._mock.command.status().times(Infinity).reply(200, { value: {} })
        this._mock.command.newSession().times(Infinity).reply(200, () => {
            newSession.value.sessionId = uuidv4()
            return newSession
        })
        this._mock.command.deleteSession().times(2).reply(200, deleteSession)
        this._mock.command.getTitle().times(Infinity).reply(200, { value: 'Mock Page Title' })
        this._mock.command.getUrl().times(Infinity).reply(200, { value: 'https://mymockpage.com' })
        this._mock.command.getElementRect(ELEMENT_ID).times(2).reply(200, { value: { width: 1, height: 2, x: 3, y: 4 } })
        this._mock.command.getElementRect(ELEMENT_ALT).times(3).reply(200, { value: { width: 10, height: 20, x: 30, y: 40 } })
        this._mock.command.getElementRect(ELEMENT_REFETCHED).times(1).reply(200, { value: { width: 1, height: 2, x: 3, y: 4 } })
        this._mock.command.getElementCSSValue(ELEMENT_ID, 'display').times(Infinity).reply(200, { value: 'block' })
        this._mock.command.getLogTypes().reply(200, { value: [] })
    }

    beforeSession(config: WebdriverIO.Config): void {
        config.hostname = 'localhost'
        config.port = 4444
    }

    before (
        caps: unknown,
        specs: unknown,
        browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser
    ) {
        this._browser = browser

        /**
         * register request interceptors for specific scenarios
         */
        this._browser.addCommand('waitForElementScenario', this.waitForElementScenario.bind(this))
        this._browser.addCommand('isNeverDisplayedScenario', this.isNeverDisplayedScenario.bind(this))
        this._browser.addCommand('isEventuallyDisplayedScenario', this.isEventuallyDisplayedScenario.bind(this))
        this._browser.addCommand('staleElementRefetchScenario', this.staleElementRefetchScenario.bind(this))
        this._browser.addCommand('customCommandScenario', this.customCommandScenario.bind(this))
        this._browser.addCommand('customSelectorScenario', this.customSelectorScenario.bind(this))
        this._browser.addCommand('waitForDisplayedScenario', this.waitForDisplayedScenario.bind(this))
        this._browser.addCommand('cucumberScenario', this.cucumberScenario.bind(this))
        this._browser.addCommand('clickScenario', this.clickScenario.bind(this))
        this._browser.addCommand('isExistingScenario', this.isExistingScenario.bind(this))
        this._browser.addCommand('isNotExistingScenario', this.isNotExistingScenario.bind(this))
        this._browser.addCommand('multiremoteFetch', this.multiremoteFetch.bind(this))
        this._browser.addCommand('asyncIterationScenario', this.asyncIterationScenario.bind(this))
        this._browser.addCommand('parentElementChaining', this.parentNextPreviousElementChaining.bind(this))
        this._browser.addCommand('refetchElementScenario', this.refetchElementScenario.bind(this))
        this._browser.addCommand('waitForExistScenario', this.waitForExistScenario.bind(this))
        this._browser.addCommand('executeMemLeakScenario', this.executeMemLeakScenario.bind(this))
        this._browser.addCommand('requestRetryScenario', this.requestRetryScenario.bind(this))
    }

    clickScenario() {
        this.nockReset()
        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }

        this._mock.command.findElement().times(2).reply(200, { value: elemResponse })
        this._mock.command.elementClick(ELEMENT_ID).once().reply(200, { value: null })
    }

    isExistingScenario() {
        this.nockReset()
        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }

        this._mock.command.findElement().times(1).reply(200, { value: elemResponse })
        this._mock.command.findElementFromElement(ELEMENT_ID).times(2).reply(200, { value: elemResponse })
        this._mock.command.findElementsFromElement(ELEMENT_ID).times(2).reply(200, { value: [elemResponse] })
    }

    isNotExistingScenario() {
        this.nockReset()
        this._mock.command.findElement().reply(404, NO_SUCH_ELEMENT)
    }

    waitForElementScenario() {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }

        this._mock.command.findElement().once().reply(404, NO_SUCH_ELEMENT)
        this._mock.command.findElement().times(2).reply(200, { value: elemResponse })
        this._mock.command.findElements().times(5).reply(200, { value: [] })
        this._mock.command.findElements().reply(200, { value: [elemResponse] })
        this._mock.command.elementClick(ELEMENT_ID).once().reply(200, { value: null })
    }

    isNeverDisplayedScenario() {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }

        this._mock.command.findElement().times(2).reply(404, NO_SUCH_ELEMENT)
        this._mock.command.findElement().times(2).reply(200, { value: elemResponse })
        this._mock.command.executeScript(ELEMENT_ID).once().reply(200, { value: false })
    }

    isEventuallyDisplayedScenario() {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }

        this._mock.command.findElement().times(1).reply(404, NO_SUCH_ELEMENT)
        this._mock.command.findElement().times(2).reply(200, { value: elemResponse })
        this._mock.command.executeScript(ELEMENT_ID).times(2).reply(200, { value: true })
    }

    staleElementRefetchScenario() {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        const elem2Response = { [ELEM_PROP]: ELEMENT_REFETCHED }

        // Found initially
        this._mock.command.findElement().once().reply(200, { value: elemResponse })
        // Initiate refetch, but its not ready
        this._mock.command.findElement().once().reply(404, NO_SUCH_ELEMENT)
        // Always return the new element after
        this._mock.command.findElement().times(4).reply(200, { value: elem2Response })

        // First click works
        this._mock.command.elementClick(ELEMENT_ID).once().reply(200, { value: null })
        // Additional clicks won't for the original element
        this._mock.command.elementClick(ELEMENT_ID).times(8).reply(500, {
            value: {
                error: 'stale element reference',
                message: 'element is not attached to the page document'
            }
        })
        // Clicks on the new element are successful
        this._mock.command.elementClick(ELEMENT_REFETCHED).times(4).reply(200, { value: null })

        // Wait for it to exist - but 2 failed iterations
        this._mock.command.findElements().times(2).reply(200, { value: [] })
        // Always appears thereafter
        this._mock.command.findElements().times(4).reply(200, { value: [elem2Response] })
    }

    refetchElementScenario() {
        const elemResponse1 = { [ELEM_PROP]: '1' }
        const elemResponse2 = { [ELEM_PROP]: '2' }
        const elemResponse3 = { [ELEM_PROP]: '3' }
        const elemResponse4 = { [ELEM_PROP]: '4' }
        this._mock.command.getElementText('1').reply(200, { value: 'some element text 1' })
        this._mock.command.getElementText('2').reply(200, { value: 'some element text 2' })
        this._mock.command.getElementText('3').reply(200, { value: 'some element text 3' })
        this._mock.command.getElementText('4').reply(200, { value: 'some element text 4' })
        this._mock.command.findElements().times(4).reply(200, { value: [] })
        this._mock.command.findElements().times(1).reply(200, { value: [elemResponse1] })
        this._mock.command.findElements().times(1).reply(200, { value: [elemResponse1, elemResponse2] })
        this._mock.command.findElements().times(1).reply(200, { value: [elemResponse1, elemResponse2, elemResponse3] })
        this._mock.command.findElements().times(1).reply(200, { value: [elemResponse1, elemResponse2, elemResponse3, elemResponse4] })
    }

    waitForExistScenario() {
        const elemResponse = { [ELEM_PROP]: '1' }
        this._mock.command.findElement().times(1).reply(404, NO_SUCH_ELEMENT)
        this._mock.command.findElement().times(1).reply(200, { value: elemResponse })
        this._mock.command.findElements().times(4).reply(200, { value: [] })
        this._mock.command.findElements().times(2).reply(200, { value: [elemResponse] })
    }

    asyncIterationScenario () {
        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        const elem2Response = { [ELEM_PROP]: ELEMENT_REFETCHED }
        this._mock.command.findElements().reply(200, { value: [elemResponse, elem2Response] })
        this._mock.command.getElementText(ELEMENT_ID).reply(200, { value: 'some element text' })
        this._mock.command.getElementText(ELEMENT_REFETCHED).reply(200, { value: 'some other element text' })
        return [ELEMENT_ID, ELEMENT_REFETCHED]
    }

    parentNextPreviousElementChaining () {
        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        const elemParentResponse = { [ELEM_PROP]: ELEMENT_REFETCHED }
        this._mock.command.findElement().reply(200, { value: elemResponse })
        this._mock.command.executeScript().reply(200, { value: elemParentResponse })
        this._mock.command.getElementText(ELEMENT_REFETCHED).reply(200, { value: 'some element text' })
    }

    multiremoteFetch () {
        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        const elem2Response = { [ELEM_PROP]: ELEMENT_REFETCHED }

        this._mock.command.findElement().twice().reply(200, { value: elemResponse })
        this._mock.command.findElementFromElement(ELEMENT_ID).twice().reply(200, { value: elem2Response })
        this._mock.command.elementClick(ELEMENT_REFETCHED).twice().reply(200, { value: null })
    }

    customCommandScenario(times = 1) {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        const elemAltResponse = { [ELEM_PROP]: ELEMENT_ALT }
        this._mock.command.findElement().times(times).reply(200, { value: elemResponse })
        this._mock.command.findElement().times(times).reply(200, { value: elemAltResponse })
        this._mock.command.executeScript().times(times).reply(200, { value: '2' })

        // overwrite
        this._mock.command.deleteAllCookies().times(times).reply(200, { value: 'deleteAllCookies' })
    }

    customSelectorScenario() {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        const elemAltResponse = { [ELEM_PROP]: ELEMENT_ALT }
        this._mock.command.findElement().reply(200, { value: elemResponse })
        this._mock.command.executeScript().reply(200, { value: elemAltResponse })
        this._mock.command.findElementFromElement(ELEMENT_ALT).reply(200, { value: elemResponse })
    }

    waitForDisplayedScenario() {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        this._mock.command.findElement().once().reply(200, { value: elemResponse })
        this._mock.command.executeScript(ELEMENT_ID).times(4).reply(200, { value: false })
        this._mock.command.executeScript(ELEMENT_ID).times(2).reply(200, { value: true })
    }

    cucumberScenario() {
        this.nockReset()

        const elemResponse = { [ELEM_PROP]: ELEMENT_ID }
        this._mock.command.navigateTo().reply(200, { value: null })
        this._mock.command.findElement().times(4).reply(200, { value: elemResponse })
        this._mock.command.elementClick(ELEMENT_ID).reply(200, { value: null })
    }

    executeMemLeakScenario(executeCalls: number) {
        this.nockReset()

        this._mock.command.executeScript().times(executeCalls).reply(200, {
            statusCode: 200,
            value: 'mockResponse',
        })

        // due to memory leaks in nock, we have to reset it from within the test
        // before measuring our actual memory usage
        return () => this.nockReset()
    }

    requestRetryScenario() {
        this.nockReset()

        this._mock.command.navigateTo().times(3).reply(500, {})
        this._mock.command.navigateTo().reply(200, { value: null })
    }

    nockReset() {
        nock.cleanAll()
        nock.abortPendingRequests()
        this.init()
    }
}

/**
 * export for 3rd party usage
 */
export { WebDriverMock }

export const launcher = class WebdriverMockLauncher {
    onPrepare(config: WebdriverIO.Config) {
        config.hostname = 'localhost'
        config.port = 4444
        config.runnerEnv = {
            ...(config.runnerEnv || {}),
            WDIO_USE_NATIVE_FETCH: 'true'
        }
    }
}
